Skip to content

NativeEngine: texture upload, cubemap loading, and readback improvements - #1808

Open
bkaradzic-microsoft wants to merge 9 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/texture-upload-formats
Open

NativeEngine: texture upload, cubemap loading, and readback improvements#1808
bkaradzic-microsoft wants to merge 9 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:pr/texture-upload-formats

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Jul 31, 2026

Copy link
Copy Markdown
Member

What

Fills in a set of gaps in NativeEngine's texture upload / readback paths so that Babylon.js texture APIs that already work on WebGL behave the same on Native.

Seven self-contained commits:

Commit Change
975b63d8 Load single-file .dds / .ktx / .ktx2 cubemaps, including embedded spherical-harmonic coefficients
b1bc011f Implement NativeEngine::updateTextureData
43dedc29 Add the updateTextureDirectly texture-loader sink
9871bf8e Route cube-texture UpdateTextureData to bgfx::updateTextureCube
138e4577 Fix a crash loading BC1/DXT1 textures
35f2cdad Support cube-map face readback in NativeEngine.readTexture
d4d8d438 Native raw 3D textures, plus a sampler3D texelFetch coordinate-flip fix

Each commit builds and runs on its own; they're ordered so the plumbing lands before the callers.

Validation

Full Playground validation suite, RelWithDebInfo, Win32, D3D11:

Run complete. ran=301 passed=301 failed=0 missingRef=0 skipped=419

No regressions against master's baseline of 300/300.

About config.json

This PR un-excludes exactly one test — Test updateTextureData — and that one is verified to pass against the stock npm babylonjs 9.15.0 that Apps/node_modules resolves to.

I want to flag this explicitly because it bit me: while developing this I had a locally-built Babylon.js fork in Apps/node_modules (12.7 MB babylon.max.js, same declared version 9.15.0 as the 8.6 MB npm build). Against that fork, 16 tests appeared to pass. Against stock npm, only 1 of the 16 actually does. The other 15 need Babylon.js-side changes that haven't landed yet:

  • cubemap tests → need a Babylon.js-side single-file cubemap loader path. There is no open PR for this today: [Native] Load single-file .dds/.ktx/.ktx2 cubemaps on the native engine Babylon.js#18560 was the attempt, but it was closed unmerged on 2026-06-17 and the work landed nowhere, so nativeEngine.cubeTexture.pure.ts on master still throws Cannot load cubemap because 6 files were not defined for a single file.
  • CDF renderer tests → CDF renderer is not supported by the current engine
  • TEXTURE_3D / FLOAT tests → require the raw-3D-texture constants to be plumbed through the engine caps

Those un-exclusions are deliberately not in this PR and will follow once the corresponding Babylon.js work is released.

Note for anyone touching the shader compiler

d4d8d438 includes a fix in ShaderCompilerTraversers.cpp that is worth calling out, because the failure mode is nasty and invisible in the common configuration.

BabylonNative builds SPIRV-Cross with SPIRV_CROSS_WEBMIN (see the root CMakeLists.txt; BABYLON_NATIVE_DISABLE_WEBMIN turns it off). In that configuration a number of opcodes — including OpIMul — are compiled out to SPIRV_CROSS_INVALID_CALL(), which is a bare assert(false). Under NDEBUG that is a no-op: the instruction is visited, no result id is set, and the failure surfaces much later at the first consumer of that id as Cannot resolve expression type. — and since SPIRV_CROSS_THROW is also stripped to throw CompilerError("") under WEBMIN, the message you actually get is empty.

Concretely: emitting coord * ivec2(1, -1) from a traverser produces a silently broken HLSL/MSL/Vulkan shader. The fix here computes the flip as ivec2(coord.x, textureSize(s, lod).y - 1 - coord.y), which only needs OpCompositeExtract / OpCompositeConstruct / OpISub — all of which WEBMIN retains. Because that formulation references the coordinate subtree twice, EOpTextureFetch handling also moved from EvPreVisit to EvPostVisit so the traverser doesn't descend into the duplicated subtree and double-flip nested texture() calls.

Short version: don't emit integer multiply from a shader-compiler traverser. I'll file this upstream against SPIRV-Cross separately — a stripped opcode should fail loudly rather than emit a broken shader.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends BabylonNative’s NativeEngine texture pipeline so Babylon.js texture upload/load/readback behaviors match WebGL more closely, including cubemap container loading, incremental uploads, cube-face readback, and raw 3D texture support.

Changes:

  • Add NativeEngine::updateTextureData and updateTextureDirectly to support incremental texture uploads and Babylon.js loader “direct upload” sinks.
  • Enable single-file cubemap container loading (.dds/.ktx/.ktx2) and compute diffuse-IBL spherical-harmonics from decoded top mips.
  • Add cube-face readback support to readTexture, plus 3D raw texture creation/upload plumbing and shader-compiler texelFetch coordinate flip fixes.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Plugins/ShaderCompiler/Source/ShaderCompilerTraversers.cpp Moves texelFetch coordinate flipping into an AST post-visit rewrite, avoiding WEBMIN-stripped integer multiply paths.
Plugins/ShaderCompiler/Source/ShaderCompilerCommon.cpp Turns ProcessSamplerFlip into an identity passthrough since flipping is handled in the AST.
Plugins/NativeEngine/Source/NativeEngine.h Adds new NativeEngine entrypoints for incremental updates, 3D raw textures, and direct upload sink.
Plugins/NativeEngine/Source/NativeEngine.cpp Implements cubemap container loading + SH computation, updateTextureData, updateTextureDirectly, raw 3D textures, and cube-face readback routing.
Core/Graphics/Source/Texture.cpp Adds 3D texture create/update and tracks cube/3D flags on Texture objects.
Apps/Playground/Scripts/config.json Un-excludes the Test updateTextureData playground test from automatic testing.

Comment thread Plugins/NativeEngine/Source/NativeEngine.cpp
Comment thread Plugins/NativeEngine/Source/NativeEngine.cpp
Comment thread Core/Graphics/Source/Texture.cpp Outdated
bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Jul 31, 2026
Three fixes from review on BabylonJS#1808:

- UpdateTextureData: the bounds check allows `layer` up to 6*numLayers for
  cube textures (six faces per array layer), but the call site passed that
  value straight through as the bgfx *side* and hardcoded array layer 0.
  For a cube array that meant a side index above 5 and every update landing
  on layer 0. Decompose `layer` into (layer / 6, layer % 6) so it matches
  the range the bounds check actually admits.

- ReadTexture: the face/layer index was forwarded to encoder->blit as srcZ
  with no upper bound, so an out-of-range value could drive an out-of-bounds
  read inside bgfx. Validate it against the texture's srcZ extent.

  Note this is deliberately not a flat 0-5 check: Babylon.js passes this same
  argument for 2D arrays as a slice index (see BaseTexture.readPixels, which
  takes the faceIndex branch for `isCube || is2DArray`), where values above 5
  are legitimate. The bound is 6*numLayers for cube textures and numLayers
  otherwise, matching UpdateTextureData.

- Texture::Create3D: drop a duplicated `m_is3D = false;` store left over from
  copy/paste before the correct `m_is3D = true;`.

Revalidated: ran=301 passed=301 failed=0 (RelWithDebInfo, Win32, D3D11).
bkaradzic and others added 8 commits August 4, 2026 07:48
…armonics

loadCubeTexture now accepts a single self-contained cubemap container (all six
faces + mips), decoded via bimg::imageParse, and uploads sides 0-5 x mips.
ComputeCubeSphericalPolynomial derives the diffuse-IBL spherical harmonics from
the top-mip faces (port of CubeMapToSphericalPolynomialTools) and returns the
polynomial coefficients to JS. This is done natively because the WebGL upload and
cube-readback paths are unimplemented on native and .dds stores no SH.

The 6 prefiltered-environment PBR validation tests this unblocks stay excluded
here; they need the paired Babylon.js change to ship in the babylonjs dependency
first.

Pairs with BabylonJS/Babylon.js#18560 (native createCubeTexture dispatch for
single-URL containers). Depends on a babylonjs dependency bump including it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
…ureData)

updateTextureData previously threw "not implemented" on Native. Implement it so
sub-rectangle texture updates work.

- Add NativeEngine::UpdateTextureData: upload the requested sub-rectangle via
  bgfx::updateTexture2D (Texture::Update2D). Validates the JS-controlled rect
  against the mip extents, sizes the copy with bgfx::calcTextureSize (no bimg
  dependency, so it also works in no-image-loading builds), and mirrors the
  vertical flip the base texture upload applies so the sub-rect lines up on
  top-left-origin backends (e.g. D3D11).
- Re-enable the "Test updateTextureData" validation test.

Pairs with the Babylon.js change (engine.name = "Native" so name-gated WebGL
_gl access skips Native, plus the updateTextureData override). CI stays red
until a babylonjs npm with that change is published and the dependency bumped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implement the native sink for Babylon's _uploadDataToTextureDirectly /
_uploadCompressedDataToTextureDirectly so single-file container textures
(.dds/.ktx/.ktx2, plus Basis/IES/HDR/EXR/TGA) load through the same JS
texture loaders WebGL/WebGPU use. The loaders upload one (face, mip) at a
time WebGL texImage2D-style; bgfx needs the whole texture allocated first,
so the underlying texture is created lazily on the first upload.

Validates JS-provided dimensions against maxTextureSize before uint16
narrowing and the payload size against bimg::imageGetSize, uses bgfx::copy
for async-owned upload memory, and matches the existing loader flip
conventions (skipping row-flips for compressed formats).

Addresses BabylonJS#218 (paired with the Babylon.js single-file cubemap loader change).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tureCube

A cube created with bgfx::createTextureCube must be updated with
bgfx::updateTextureCube (per-face side index), not updateTexture2D. Add an
IsCube() flag (set in Texture::CreateCube, cleared in Create2D/Attach) and, in
NativeEngine::UpdateTextureData, branch cubes to Texture::UpdateCube(0, side,
mip, ...). Widen the layer bounds check to 6*numLayers for cubes (the JS side
passes the face index in the layer arg).

This is the C++ half of the HDR createRawCubeTexture fix. The IBL tests it
unblocks stay excluded here: they also need the Babylon.js-side half, which is
not in the pinned babylonjs dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
PrepareImage passed block-compressed / unsupported formats straight to
bimg::imageGenerateMips, which only supports RGBA8/RGBA32F and returns NULL
otherwise. The NULL image was then dereferenced in LoadTextureFromImage,
crashing with an access violation. Tests 250/251/252 ("PBR shader code
coverage 1/2/3", snippets #QI7TL3#63/64/65) load a 256x256 BC1 texture with
generateMips=true and hit this.

- PrepareImage: convert any non-RGBA8/RGBA32F format before imageGenerateMips
  (float/high-precision -> RGBA32F, everything else incl. BC1/DXT1 -> RGBA8),
  with a null-check on the imageConvert result.
- LoadTexture: throw (routes to onError) instead of dereferencing a null image.

The crash is eliminated on all three. They stay excluded here: they also load
a single-file environment cubemap, which needs the Babylon.js-side change that
is not in the pinned babylonjs dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
readTexture ignored the cube face and always read srcZ=0, and the JS
_readTexturePixels threw for any cube faceIndex. As a result
readPixels(face) returned null and ConvertCubeMapToSphericalPolynomial
crashed with "Cannot read properties of null" for tests that compute
diffuse-IBL spherical harmonics from a dynamically rendered cube
("Realtime Filtering", "Refraction local cube map PBR").

- readTexture now accepts an optional faceIndex (info[9], -1 = plain 2D).
  A cube-face read always routes through the blit path with srcZ = face
  (bgfx::readTexture cannot address an individual cube face).

Both tests stay excluded here: they also require Babylon.js-side changes
that are not in the pinned babylonjs dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Graphics::Texture gains Create3D/Update3D (bgfx createTexture3D/
updateTexture3D) and NativeEngine gains a loadRawTexture3D binding, giving
Babylon.js createRawTexture3D/updateRawTexture3D real 3D volumes on Native.

Fix the sampler3D shader compile that blocks HAL Lattice: the vertical
texel-coordinate flip was applied by a preprocessor macro that forced every
texelFetch coordinate through ivec2(...), so sampler3D fetches failed with
'no matching overloaded function'. Move that flip into the dimension-aware
FlipSamplerCoordinates AST traverser (only 2-component integer coords are
flipped; 3D/array left intact), cloning the sampler and lod operands so the
injected textureSize() call does not alias the original texelFetch subtree
(aliasing corrupted the AST and crashed unrelated async tests on dispose).

The flip is built as ivec2(uv.x, textureSize(s, lod).y - 1 - uv.y), matching
the expression the old macro expanded to. The tidier vector form
uv * ivec2(1, -1) + ivec2(0, size.y - 1) must not be used: it emits SPIR-V
OpIMul, which SPIRV-Cross drops entirely in the SPIRV_CROSS_WEBMIN
configuration Babylon Native builds (the handler is compiled out to a
release-mode no-op assert). The multiply then yields no HLSL/MSL expression
and the whole shader fails to cross-compile with "Cannot resolve expression
type" - which regressed "Gaussian Splatting Compressed ply SH", the only
enabled test that texelFetches a usampler2D. Integer subtract and vector
construction are both retained by that build.

The texelFetch rewrite runs on post-visit because it references the
coordinate subtree twice; rewriting on the way down would make the traverser
descend into that subtree twice and double-flip any nested texture() call.

HAL Lattice (idx 128) stays excluded: it additionally needs Babylon.js-side
3D texture support, which is not in the pinned babylonjs dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Three fixes from review on BabylonJS#1808:

- UpdateTextureData: the bounds check allows `layer` up to 6*numLayers for
  cube textures (six faces per array layer), but the call site passed that
  value straight through as the bgfx *side* and hardcoded array layer 0.
  For a cube array that meant a side index above 5 and every update landing
  on layer 0. Decompose `layer` into (layer / 6, layer % 6) so it matches
  the range the bounds check actually admits.

- ReadTexture: the face/layer index was forwarded to encoder->blit as srcZ
  with no upper bound, so an out-of-range value could drive an out-of-bounds
  read inside bgfx. Validate it against the texture's srcZ extent.

  Note this is deliberately not a flat 0-5 check: Babylon.js passes this same
  argument for 2D arrays as a slice index (see BaseTexture.readPixels, which
  takes the faceIndex branch for `isCube || is2DArray`), where values above 5
  are legitimate. The bound is 6*numLayers for cube textures and numLayers
  otherwise, matching UpdateTextureData.

- Texture::Create3D: drop a duplicated `m_is3D = false;` store left over from
  copy/paste before the correct `m_is3D = true;`.

Revalidated: ran=301 passed=301 failed=0 (RelWithDebInfo, Win32, D3D11).
@bkaradzic-microsoft
bkaradzic-microsoft force-pushed the pr/texture-upload-formats branch from 73b33cf to 5d8c2e1 Compare August 4, 2026 14:59

@bghgary bghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

Concerns inline.

Separately, the deferred cubemap un-exclusions point at a dead link. The description names Babylon.js#18560 as the prerequisite, but that PR was closed on 2026-06-17 without merging, and the work did not land elsewhere: nativeEngine.cubeTexture.pure.ts on master was last touched by #18441 (tree-shaking) and still throws Cannot load cubemap because 6 files were not defined for a single file. No open Babylon.js PR replaces it.

TIntermTyped* lodClone{CloneLeaf(lod)};
if (samplerClone == nullptr || lodClone == nullptr)
{
return coordinate;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

CloneLeaf returns nullptr for anything that is not a symbol or a constant, and the flip is then skipped silently. texelFetch(s, coord, someComputedLod) samples with un-flipped Y and produces a wrong image with no diagnostic. Worth throwing or falling back rather than returning the coordinate unchanged.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in a3241c1CloneLeaf is gone, replaced by CloneExpression, a structural deep clone that handles symbols, constant unions, binary, unary and aggregate nodes. It copies each node's operator, type and source location verbatim rather than rebuilding through TIntermediate::add*, so no constant folding or type promotion can make the copy diverge from the original. Anything it genuinely cannot copy now throws std::runtime_error instead of returning nullptr, so we can never again silently emit an unflipped coordinate.

Two notes from checking how much this actually mattered:

I surveyed the 133 texelFetch call sites in the Babylon.js shaders. lod is almost always a constant (0, 1) or a bare symbol, so the old leaf-only clone was usually enough for it. But coordinates are frequently complexivec2(gl_FragCoord.xy), ivec2(vUV * texSize), coord + ivec2(1,1), ivec2(t0 % w, t0 / w) are all common. So the nullptr path was not an exotic corner; the reason it did not show up as failures is the second point below.

One behaviour I want to flag explicitly rather than have it found later: cloning an EOpFunctionCall aggregate duplicates a side-effecting call. That is not a regression — it is exactly what the ProcessSamplerFlip macro this code replaced already did, since it expanded the coordinate operand twice — but it is inherent to expressing the flip as ivec2(c.x, size.y - 1 - c.y) and worth knowing about.

// coordinate.x and coordinate.y
TIntermTyped* coordinateX{m_intermediate->addIndex(EOpIndexDirect, coordinate, m_intermediate->addConstantUnion(0, loc), loc)};
coordinateX->setType(intType);
TIntermTyped* coordinateY{m_intermediate->addIndex(EOpIndexDirect, coordinate, m_intermediate->addConstantUnion(1, loc), loc)};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

sampler and lod are cloned because reusing a node would give it two parents, but coordinate is referenced twice right here — once for .x on the line above, once for .y here — and is not cloned. By the argument in the comment at the top of this function, that is the same aliasing: the original reference is replaced by the returned aggregate, but two new ones are created in its place.

Is the coordinate safe for a reason the sampler and lod are not, or should it be cloned too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, and thank you for pushing on it — the answer to "was the coordinate safe for a different reason?" is no, it was not safe at all. Fixed in a3241c1: the coordinate is now cloned too, with the original node supplying one reference and the clone the other, so every node ends up with exactly one parent.

The post-visit ordering only masked one symptom (a nested texelFetch being flipped twice). The aliased DAG still went on to sampler splitting and SPIR-V generation, which is precisely the situation the comment right above cites as the reason for cloning sampler and lod. I have updated that comment so it now explains why all three operands are cloned instead of implying the coordinate is special.

For coverage, I extended ShaderCompilation.CompileComprehensiveGLSL rather than relying on "the render suite still passes". That distinction turned out to matter: I instrumented the traverser and ran the full 301-test validation suite, and it only hit three texelFetch flips, all of them with a bare symbol coordinate and a constant lod — i.e. the suite gave the new code zero coverage. The test now exercises a constructor, a binary expression, a nested constructor over a float expression, a nested constructor over integer binaries, built-in calls, and a non-constant lod, which cover the unary, binary and aggregate clone paths and a symbol lod. Verified by the same instrumentation that all of those shapes now reach CloneExpression.

While writing that test I hit two unrelated constructs that the WEBMIN builds reject — integer multiply (the OpIMul case already called out in this PR) and bitwise &. I kept them out of the test and left a comment saying why, so nobody re-adds them and blames the flip.

m_ownsHandle = true;
m_width = width;
m_height = height;
m_depth = depth;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

m_depth is only ever assigned here, and Dispose() clears just the handle — so a Texture re-created as 2D or cube after having been 3D keeps the old depth and Depth() returns a stale value. The sibling Create* methods each reset m_is3D but none of them reset m_depth.

Each Create* hand-assigning its own subset of the metadata is what made the dead m_is3D store possible too; a shared reset would close both.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a3241c1. m_depth was assigned only by Create3D, and Dispose() clears just the handle, so a Texture re-created as 2D or cube after having been 3D kept reporting the old depth.

I took the shared-reset route you suggested rather than adding one more hand-written assignment. There is now a private ResetMetadata() that returns every shape field to its default, and Create2D, Create3D, CreateCube and Attach all call it immediately after Dispose() before assigning the subset that applies to them. That also let me delete the scattered m_isCube = false; m_is3D = false; lines, which is what made the class of bug possible in the first place: any field a given path forgets is now defaulted rather than inherited from the previous, differently shaped texture.

const bgfx::Memory* mem{bgfx::alloc(requiredSize)};
if (flip)
{
const uint32_t rowBytes{requiredSize / height};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

requiredSize / height is only a row stride for uncompressed formats. Measured with bgfx::calcTextureSize on this branch (Win32 D3D11, RelWithDebInfo):

format size storageSize requiredSize / height correct block-row stride
BC1 4x4 8 2 8
BC1 16x16 128 8 32
BC3 / BC7 4x4 16 4 16
RGBA8 4x4 64 16 16

So for a 4x4 BC1 the loop makes 4 passes of 2 bytes over a single 8-byte block, reversing the [color0:2][color1:2][indices:4] layout into garbage; at 16x16 it reverses 16 8-byte chunks where the real layout is 4 block-rows of 32. Only the uncompressed row matches.

Reachable by default rather than in principle: bgfx::getCaps()->originBottomLeft is 0 on D3D11 (bgfx logs origin top left), so flip = originBottomLeft ? invertY : !invertY is true whenever invertY is left at its default.

bgfx::updateTexture2D also needs block-aligned x/y/width/height for compressed textures, which is not checked either. Rejecting compressed formats here would be enough, given the base upload path already handles them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and your arithmetic is right. I checked the block info in bimg (image.cpp:30, {4, 4, 4, 8, ...} for BC1 — a 4x4 block in 8 bytes): at 4x4 requiredSize / height gives 8/4 = 2 against a real block row of 8, and at 16x16 it gives 128/16 = 8 against a correct 32.

It is also reachable by default rather than theoretical: originBottomLeft is only ever set true in renderer_gl.cpp, so on D3D11 it is 0 and flip = !invertY is true whenever invertY is left false.

Fixed in a3241c1 by rejecting block-compressed formats outright, as you suggested, rather than fixing the stride. Fixing the stride would not be enough: mirroring block rows cannot flip a texture vertically without re-encoding the texel rows packed inside each block. BC1 happens to keep its 4 index bytes in row order, but BC3 alpha and BC7 do not, so a correct flip means a full decode/re-encode. bgfx also wants block-aligned x/y/width/height here, which the code was not checking either; rejecting the formats moots that too.

The base upload path (loadTexturePrepareImage) already handles compressed data, so this is not a capability loss, and the error message says so. The check is texture->Format() < bgfx::TextureFormat::Unknown, which works because every block-compressed format sorts before Unknown in bgfx's enum (bgfx.h:222, "Compressed formats above"). That keeps it bimg-free, which matters here since this file has to build in configurations where bimg is not linked.

…tureData

Clone whole texel coordinate expressions instead of leaves only.

CloneLeaf handled symbols and constant unions and returned nullptr for
everything else, and the caller treated nullptr as "skip the flip". Any
texelFetch whose sampler or lod was not a bare leaf therefore sampled with an
un-flipped Y and produced a wrong image with no diagnostic. Replace it with
CloneExpression, a structural deep clone covering symbols, constant unions,
binary, unary and aggregate nodes, which throws instead of silently skipping
when it meets something it cannot copy.

The coordinate itself was also referenced twice, for .x and .y, without being
cloned. That is the same aliasing the surrounding comment cites as the reason
for cloning the sampler and lod, so clone it too and let the original supply
one reference and the clone the other, leaving every node with exactly one
parent.

Cover the new paths in the comprehensive GLSL compilation test with the
coordinate shapes that actually occur in Babylon shaders: a constructor, a
binary expression, a nested constructor over a float expression, a nested
constructor over integer binaries, built-in calls, and a non-constant lod.
These exercise the unary, binary and aggregate clone paths, none of which the
old code could handle. Integer multiply, divide, modulo and bitwise operators
are avoided in the test because glslang and SPIRV-Cross are built in their
WEBMIN configurations here and reject them for unrelated reasons.

Reset texture metadata in one place.

m_depth was only ever assigned by Create3D, so a Texture re-created as 2D or
cube after having been 3D kept reporting the old depth. Rather than add one
more hand-written assignment to each Create*, give them a shared
ResetMetadata() so a field that a given path does not set cannot survive from
the previous, differently shaped texture.

Reject block-compressed formats in updateTextureData.

The vertical flip derived its row stride as requiredSize / height, which is
only a row stride for uncompressed formats. For BC1 at 4x4 that yields 2 bytes
against a real block row of 8. Even with the right stride, mirroring block rows
cannot flip a texture vertically without re-encoding the texel rows packed
inside each block, and bgfx additionally requires block-aligned coordinates
here. Reject these formats with a clear error; the base upload path already
handles compressed data, so nothing is lost.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Thanks for the review — all four were real, and all four are fixed in a3241c1. Replies are on the individual threads; summary here.

  • texelFetch operand cloning. CloneLeaf is replaced by CloneExpression, a structural deep clone (symbol / constant union / binary / unary / aggregate) that throws rather than silently returning an unflipped coordinate. The coordinate is now cloned as well as the sampler and lod.
  • Texture metadata. New shared ResetMetadata() called from Create2D, Create3D, CreateCube and Attach, so m_depth (and anything added later) cannot go stale.
  • updateTextureData. Block-compressed formats are now rejected with a clear error instead of being fed through a row-reversal loop with a bogus stride.
  • Description. Corrected. [Native] Load single-file .dds/.ktx/.ktx2 cubemaps on the native engine Babylon.js#18560 was closed unmerged on 2026-06-17 and the work landed nowhere, so the deferred cubemap un-exclusions have no current prerequisite PR — I have said that plainly rather than pointing at a dead link.

Validation: full render suite 301/301 (no regressions), Apps/UnitTests 17/17.

One side-finding worth its own issue, which I am not changing here. Dependencies/CMakeLists.txt sets ENABLE_GLSLANG_WEBMIN ON, and in that configuration glslang compiles out TParseContextBase::error() (ParseHelper.h, guarded by #if !defined(GLSLANG_WEB) || defined(GLSLANG_WEB_DEVEL)). The error count still increments, so getInfoLog() returns a bare:

ERROR: 3 compilation errors.  No code generated.

with no file, line, or reason — which is what every BN shader compile failure looks like today. I lost a fair amount of time to this while writing the test above; the failing constructs were only identifiable by bisecting the shader source. This is the same shape of problem as the SPIRV_CROSS_WEBMIN opcode stripping already called out in this PR: a size-optimised dependency silently discarding diagnostics. Happy to file it separately, or to send a small follow-up that turns on GLSLANG_WEB_DEVEL (or otherwise restores the message text) if you think that is the right trade against binary size.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Plugins/NativeEngine/Source/NativeEngine.cpp:1923

  • Error message grammar: this is thrown when mipmaps are requested for a 3D texture; the wording should be plural ('3D textures') to read correctly.
            throw Napi::Error::New(Env(), "Texture 3D currently do not support mipmaps.");

Plugins/NativeEngine/Source/NativeEngine.cpp:319

  • PrepareImage can now return a null image (e.g., when imageConvert fails), but other call sites in this file still pass its return value directly to LoadTextureFromImage / LoadCubeTextureFromImages without a null guard. To avoid potential null dereferences, have PrepareImage fail loudly (throw) and also check the result of imageGenerateMips, so null never escapes from this helper.
                    if (image == nullptr)
                    {
                        return nullptr;
                    }

Plugins/NativeEngine/Source/NativeEngine.cpp:1928

  • Error message: "invert Y" is ambiguous and the grammar is off. Consider referencing the parameter name (invertY) and clarifying it's a vertical flip.
            throw Napi::Error::New(Env(), "Texture 3D currently do not support invert Y.");

@bghgary bghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

Earlier four look good. Two new concerns inline.


if (TIntermAggregate* aggregate = node->getAsAggregate())
{
auto* clone = new TIntermAggregate{aggregate->getOp()};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deep clone regresses MacOS_Sanitizers: it fails on this commit and passes on both 5d8c2e1a and master aa244ec9.

intermediate.h:1707: runtime error: load of value 190, which is not a valid value for type 'bool'
  isUserDefined() <- CloneExpression:1931 <- CloneExpression:1906 <- FlipVerticalTexelCoordinate:1846

glslang initializes userDefined in TIntermAggregate() but not in TIntermAggregate(TOperator), so clones built here carry it uninitialized (190 = 0xBE fill). Default-constructing and setting the operator afterwards fixes that:

Suggested change
auto* clone = new TIntermAggregate{aggregate->getOp()};
auto* clone = new TIntermAggregate{};
clone->setOperator(aggregate->getOp());

The read at 1931 still needs handling — it dereferences the same member on the source aggregate, so copying it unconditionally would inherit the same garbage.


if (image == nullptr)
{
return nullptr;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrepareImage can now return nullptr, but only the loadTexture call site checks it (L1650). The other three pass the result straight into an unconditional dereference: L1712 into LoadTextureFromImage (L334), and L2175 / L2222 into LoadCubeTextureFromImages (L368). Guard those three, or have PrepareImage throw instead of returning nullptr.

The two other conversions in this function are unchecked as well: the sRGB imageConvert at L284 falls through to image->m_format at L299, and the imageGenerateMips result at L323 is returned without a check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants